61 — 用 R3F 制作传送门场景
简介
让我们继续用 R3F 重复原课程内容的过程。
这一次,我们要重新制作传送门场景。
我们不会重新进行整个建模、烘焙和导出,因为这些步骤与 R3F 无关。
如果你跟着之前的课程做过了,可以使用你自己的传送门,或者使用课程起始文件所提供的那个。
准备工作
起始文件的设置与上一课相同:
场景中心有一个立方体,还有 OrbitControls,这样我们就能绕着它旋转。
我们没有使用 Perf,因为我们不应该有性能方面的顾虑,但如果你愿意,可以随意添加它。
传送门的着色器(顶点和片段)已经位于 /src/shaders/portal/ 中。至于为什么缺少萤火虫的着色器,在后面的课程中就会明白了。
深色背景
这一次,我们要保留原课程的颜色。这意味着我们需要一个更暗的背景。正如我们在之前某一课中所看到的,有多种方法可以做到这一点。
让我们使用附加到场景的 <color> 这种技术。
在 Experience 的 JSX 中,创建一个 <color>:
export default function Experience()
{
return <>
<color />
{/* ... */}
</>
} 使用 args 属性向它传入一个包含颜色的数组:
<color args={ [ '#030202' ] } /> 然后用 attach 属性把它附加到场景的 background 上:
<color args={ [ '#030202' ] } attach="background" />
模型
是时候添加我们的模型了。
加载
模型文件名为 portal.glb,位于 /public/model/ 文件夹中。你可以随意将其替换为你自己的模型。
我们要使用 useGLTF。
从 @react-three/drei 导入 useGLTF:
import { useGLTF, OrbitControls } from '@react-three/drei' 并加载模型(别忘了我们不需要写 public/:
export default function Experience()
{
const model = useGLTF('./model/portal.glb')
console.log(model)
// ...
}
如果你还记得我们创建并导入模型的那一课,它由多个部分组成:
- 烘焙好的模型,我们需要为它应用一个带有烘焙纹理的
MeshBasicMaterial。 - 两个柱灯
Meshes,我们需要为它们应用一个MeshBasicMaterial - 传送门,我们需要为它应用一个
ShaderMaterial
正因如此,我们不会一次性把整个模型添加到场景中。
相反,我们会分别添加每个元素,以便对它们有更多的控制。
而这些元素已经在 nodes 属性中可用了:
const model = useGLTF('./model/portal.glb')
console.log(model.nodes) 这些名称对应我们在 Blender 中选择的名称。如果你使用自己的模型,请确保根据你的配置进行调整。
再说一次,我们可以对它进行解构:
const { nodes } = useGLTF('./model/portal.glb')
console.log(nodes)
烘焙模型
烘焙模型的节点名为 baked,现在我们可以通过 nodes.baked 访问它。
这一次,我们不会使用 <primitive>,因为我们想给 Mesh 应用自己的材质。但我们确实需要它的几何体。
在 JSX 中,创建一个 <mesh>,并把它的几何体设置为 nodes.baked.geometry:
export default function Experience()
{
// ...
return <>
{/* ... */}
<mesh geometry={ nodes.baked.geometry } />
</>
}
现在我们有了自己的模型,可以去掉那个立方体了:
默认情况下,<mesh> 上会应用一个 MeshBasicMaterial,这就是为什么我们能看到一些东西。
我们要添加自己的材质,但首先,我们需要加载纹理。
要加载纹理,我们可以使用来自 drei 的 useTexture 辅助函数。
从 @react-three/drei 导入 useTexture:
import { useTexture, useGLTF, OrbitControls } from '@react-three/drei' 现在用它加载位于 '/public/model/baked.jpg' 的纹理(记住我们不需要写 public/):
export default function Experience()
{
// ...
const bakedTexture = useTexture('./model/baked.jpg')
console.log(bakedTexture)
// ...
}
这就是可以使用、准备就绪的实际纹理。
现在我们可以在 <mesh> 中创建 <meshBasicMaterial>,并在 map 属性上使用我们的纹理:
<mesh geometry={ nodes.baked.geometry }>
<meshBasicMaterial map={ bakedTexture } />
</mesh>
哎呀,出了点问题。
别担心,这不是模型或纹理的问题。只是纹理的 y 坐标需要翻转。
由于调用 useTexture 时会立即返回纹理,我们可以用 flipY 属性立即翻转它:
const bakedTexture = useTexture('./model/baked.jpg')
bakedTexture.flipY = false
好多了。
居中
我们的模型有点偏高。
我们可以把控制器/相机上移,或者把场景下移。
让我们用来自 drei 的 Center 辅助函数把场景下移
从 @react-three/drei 导入 Center:
import { Center, useTexture, useGLTF, OrbitControls } from '@react-three/drei' 然后把 <mesh> 包裹在 <Center> 中:
<Center>
<mesh geometry={ nodes.baked.geometry }>
<meshBasicMaterial map={ bakedTexture } />
</mesh>
</Center>
柱灯
现在我们可以在柱灯上采用与基础模型类似的做法来添加它们。
在 <Center> 内部,添加一个新的 <mesh>,把它的 geometry 设置为 nodes.poleLightA.geometry:
<Center>
{/* ... */}
<mesh geometry={ nodes.poleLightA.geometry } />
</Center>
我们的柱灯在哪里?
仔细看场景的地板:
造成这个问题的原因是,我们创建了一个全新的 <mesh>,从而丢失了初始的 position、transformation 和 scale。
要修复它,我们可以再次从 nodes.poleLightA.position 施加位置:
<mesh geometry={ nodes.poleLightA.geometry } position={ nodes.poleLightA.position } />
现在,我们可以在 <mesh> 中添加 <meshBasicMaterial>,用 #ffffe5 作为它的 color 属性:
<mesh geometry={ nodes.poleLightA.geometry } position={ nodes.poleLightA.position }>
<meshBasicMaterial color="#ffffe5" />
</mesh>
另一个柱灯的工作方式完全相同。只需复制整个 <mesh>,并把 poleLightA 改成 poleLightB:
<Center>
{/* ... */}
<mesh geometry={ nodes.poleLightB.geometry } position={ nodes.poleLightB.position }>
<meshBasicMaterial color="#ffffe5" />
</mesh>
</Center>
传送门
就像我们为基础模型和柱灯所做的那样,我们可以添加节点名为 portalLight 的传送门(因为它会发光):
<Center>
{/* ... */}
<mesh geometry={ nodes.portalLight.geometry } position={ nodes.portalLight.position }>
<meshBasicMaterial color="#ffffff" />
</mesh>
</Center>
在这种情况下,我们还需要添加初始节点的 rotation:
<mesh geometry={ nodes.portalLight.geometry } position={ nodes.portalLight.position } rotation={ nodes.portalLight.rotation }>
<meshBasicMaterial color="#ffffff" />
</mesh>
现在我们有了模型的所有部分。
颜色
你可能注意到颜色有些奇怪,尤其是当我们给柱灯添加材质时。颜色应该亮得多。
原因是 R3F 默认会设置 toneMapping。
这个 toneMapping 通常是受欢迎的,有了它颜色看起来会更好,但在我们的情况中不想要它,因为我们的场景是从 Blender 烘焙而来的,而 Blender 已经通过它的色彩管理系统应用了色调映射。
我们想要得到与在 Blender 中烘焙时完全相同的颜色。
做好准备:修复这个问题非常复杂!
在 index.jsx 中,给 <Canvas> 添加 flat:
<Canvas
flat
camera={ {
fov: 45,
near: 0.1,
far: 50,
position: [ 1, 2, 6 ]
} }
>
<Experience />
</Canvas>
就这样。
在 <Canvas> 上设置 flat 会把 toneMapping 设置为 THREE.NoToneMapping。
萤火虫
是时候添加那些由漂浮在周围发光粒子构成的萤火虫了。
在原课程中,我们用一个自定义着色器创建了它们。
正如我们之前所说的,传送门的着色器已经在项目中可用了,但萤火虫的还没有。
你可能会问为什么?原因是有一个名为 Sparkles 的 drei 辅助函数,它正好能实现我们所需要的功能。
在 Experience.jsx 中,从 @react-three/drei 导入 Sparkles:
import { Sparkles, Center, useTexture, useGLTF, OrbitControls } from '@react-three/drei' 把它添加到 <Center> 中的 JSX 里:
<Center>
{/* ... */}
<Sparkles />
</Center>
你几乎看不到它们,但地板附近有一些粒子。
我们可以用 size 属性把它们变大:
<Sparkles
size={ 6 }
/>
我们可以用 scale 属性改变区域:
<Sparkles
size={ 6 }
scale={ [ 4, 2, 4 ] }
/>
它是一个对象,所以我们可以用 position-y 属性把它上移:
<Sparkles
size={ 6 }
scale={ [ 4, 2, 4 ] }
position-y={ 1 }
/>
我们还可以用 speed 属性减慢运动:
<Sparkles
size={ 6 }
scale={ [ 4, 2, 4 ] }
position-y={ 1 }
speed={ 0.2 }
/>
最后,我们可以用 count 属性减少粒子的数量:
<Sparkles
size={ 6 }
scale={ [ 4, 2, 4 ] }
position-y={ 1 }
speed={ 0.2 }
count={ 40 }
/>
传送门
我们快要完成了。剩下的就是添加我们之前用在传送门上的自定义着色器材质。
既然我们在原课程中已经做过了,就不再重新写着色器了。你可以在 /src/shaders/portal/ 文件夹中找到它们。
基础着色器
首先,我们可以把 <meshBasicMaterial> 替换为 <shaderMaterial>:
<mesh geometry={ nodes.portalLight.geometry } position={ nodes.portalLight.position } rotation={ nodes.portalLight.rotation }>
<shaderMaterial />
</mesh>
我们仍然能看到传送门是红色的,因为 Three.js 提供了默认的顶点和片段着色器。
导入着色器
导入着色器的方式与我们在前几课中所做的完全相同。名为 vite-plugin-glsl 的 Vite 插件已经添加到项目中,我们可以导入 .glsl 文件。
把 ./shaders/portal/vertex.glsl 导入为 portalVertexShader 并打印它:
import portalVertexShader from './shaders/portal/vertex.glsl'
console.log(portalVertexShader)
也导入片段着色器并打印它,只是为了确认它能正常工作:
import portalVertexShader from './shaders/portal/vertex.glsl'
import portalFragmentShader from './shaders/portal/fragment.glsl'
console.log(portalFragmentShader)
实现着色器
现在我们可以在 <shaderMaterial> 上使用我们的着色器,通过 vertexShader 属性和 fragmentShader 属性:
<shaderMaterial
vertexShader={ portalVertexShader }
fragmentShader={ portalFragmentShader }
/>
传送门消失了,但这是因为我们没有发送片段着色器所需的 uniforms。
我们需要发送 3 个 uniforms:
uTimeuColorStartuColorEnd
我们需要把这些 uniforms 作为 <shaderMaterial> 的 uniforms 属性提供。
对于 uTime,这很容易,因为它是一个数字。但对于 uColorStart 和 uColorEnd,我们需要提供 Three.js 的 Color 实例。
首先,从 three 导入 THREE:
import * as THREE from 'three' 然后把 uniform 属性提供给 <shaderMaterial>:
<shaderMaterial
vertexShader={ portalVertexShader }
fragmentShader={ portalFragmentShader }
uniforms={ {
uTime: { value: 0 },
uColorStart: { value: new THREE.Color('#ffffff') },
uColorEnd: { value: new THREE.Color('#000000') }
} }
/>
我们有了自己的传送门着色器(尽管它还没有动画),但有一种更简单的方法来完成我们刚才所做的事情。
shaderMaterial 辅助函数
一如既往,drei 提供了一个名为 shaderMaterial 的辅助函数,它会创建一个 ShaderMaterial,然后我们可以在 JSX 中使用它。它还简化了创建和更新 uniforms 的过程。
从 drei 导入 shaderMaterial:
import { shaderMaterial, Sparkles, Center, useTexture, useGLTF, OrbitControls } from '@react-three/drei' 然后,在 Experience 函数之前调用它,并把结果赋值给一个 PortalMaterial 变量:
const PortalMaterial = shaderMaterial()
export default function Experience()
{
// ...
} 除此之外,我们还需要向它提供 3 个参数。
第一个参数是 uniforms,但不带 value 属性:
const PortalMaterial = shaderMaterial(
{
uTime: 0,
uColorStart: new THREE.Color('#ffffff'),
uColorEnd: new THREE.Color('#000000')
},
// ...
) 第二个和第三个参数分别代表顶点和片段着色器:
const PortalMaterial = shaderMaterial(
{
uTime: 0,
uColorStart: new THREE.Color('#ffffff'),
uColorEnd: new THREE.Color('#000000')
},
portalVertexShader,
portalFragmentShader
) 现在,要把它转换成我们可以在 JSX 中使用的 R3F 标签,我们将像在本章开头自己实现 OrbitControls 时那样使用 extend。
从 @react-three/fiber 导入 extend:
import { extend } from '@react-three/fiber' 然后在创建 PortalMaterial 之后调用它,并传入一个对象,其属性和值都是 PortalMaterial:
const PortalMaterial = shaderMaterial(
// ...
)
extend({ PortalMaterial }) 这样做会让我们的 JSX 中可以使用 <portalMaterial>。
现在,把 <shaderMaterial> 替换为 <portalMaterial>:
<mesh geometry={ nodes.portalLight.geometry } position={ nodes.portalLight.position } rotation={ nodes.portalLight.rotation }>
<portalMaterial />
</mesh>
这个解决方案看起来可能并没有简单多少,但它与 R3F 的集成更好,我们可以在多个 <mesh> 中使用 <portalMaterial>,而且 uniforms 也更容易更新。
动画
剩下要做的就是更新 uTime uniform,而使用 shaderMaterial 辅助函数让它变得更容易了。
首先,我们需要一个对材质的引用。
从 react 导入 useRef:
import { useRef } from 'react' 然后,创建一个 portalMaterial 引用:
export default function Experience()
{
// ...
const portalMaterial = useRef()
// ...
} 之后,用 ref 属性把它与 <shaderMaterial> 关联起来:
<portalMaterial ref={ portalMaterial } /> 现在从 @react-three/fiber 导入 useFrame 钩子:
import { useFrame, extend } from '@react-three/fiber' 在 Experience 函数中调用它:
export default function Experience()
{
// ...
const portalMaterial = useRef()
useFrame((state, delta) =>
{
})
// ...
} 最后,使用 delta 更新 portalMaterial 引用的 uTime:
useFrame((state, delta) =>
{
portalMaterial.current.uTime += delta
})
如你所见,我们不必指定 uniforms 或 value。
而且我们仅用了 70 行代码(不含着色器)就完成了传送门!
进一步探索
这一课相当简单,但你现在可以使用 drei 中所有可用的辅助函数。
也许你可以试着添加其中一些。